Web Components与现代前端组件化架构

在前端开发中,我们经常使用 React、Vue、Angular 等框架来构建组件化应用。但你是否想过,有没有一种方式可以不依赖任何框架,就能创建可复用的组件?Web Components 就是答案。

Web Components 是一套浏览器原生支持的组件化标准,它让我们可以创建真正的"一次编写,到处运行"的组件。无论你使用什么框架,甚至不使用框架,都可以使用这些组件。


一、Web Components 的四大核心技术

🎯 核心理念:Web Components 让组件化回归浏览器原生,不依赖任何框架。

1. Custom Elements(自定义元素)

Custom Elements 让我们可以创建自己的 HTML 标签。

// 创建一个简单的自定义元素
class MyButton extends HTMLElement {
  constructor() {
    super()
    this.innerHTML = '<button>点击我</button>'
  }
}

// 注册自定义元素
customElements.define('my-button', MyButton)
<!-- 在 HTML 中使用 -->
<my-button></my-button>

实际效果:页面上会显示一个按钮,就像使用普通的 HTML 标签一样。

2. Shadow DOM(影子 DOM)

Shadow DOM 提供了真正的样式和结构封装。

class MyCard extends HTMLElement {
  constructor() {
    super()

    // 创建 Shadow DOM
    const shadow = this.attachShadow({ mode: 'open' })

    // 创建组件内容
    shadow.innerHTML = `
      <style>
        .card {
          border: 1px solid #ddd;
          border-radius: 8px;
          padding: 16px;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .title {
          font-size: 18px;
          font-weight: bold;
          margin-bottom: 8px;
        }
      </style>
      <div class="card">
        <div class="title">卡片标题</div>
        <div class="content">卡片内容</div>
      </div>
    `
  }
}

customElements.define('my-card', MyCard)

关键优势:样式完全隔离,不会影响页面其他元素,也不会被外部样式影响。

3. HTML Templates(HTML 模板)

HTML Templates 让我们可以定义可复用的 HTML 结构。

<!-- 定义模板 -->
<template id="user-card-template">
  <style>
    .user-card {
      display: flex;
      align-items: center;
      padding: 12px;
      border: 1px solid #eee;
      border-radius: 6px;
      margin: 8px 0;
    }
    .avatar {
      width: 40px;
      height: 40px;
      border-radius: 50%;
      margin-right: 12px;
    }
    .info {
      flex: 1;
    }
    .name {
      font-weight: bold;
      margin-bottom: 4px;
    }
    .email {
      color: #666;
      font-size: 14px;
    }
  </style>
  <div class="user-card">
    <img class="avatar" src="" alt="头像" />
    <div class="info">
      <div class="name"></div>
      <div class="email"></div>
    </div>
  </div>
</template>
// 使用模板创建组件
class UserCard extends HTMLElement {
  constructor() {
    super()

    const template = document.getElementById('user-card-template')
    const templateContent = template.content.cloneNode(true)

    this.attachShadow({ mode: 'open' }).appendChild(templateContent)
  }

  // 设置用户数据
  setUser(user) {
    const shadow = this.shadowRoot
    shadow.querySelector('.avatar').src = user.avatar
    shadow.querySelector('.avatar').alt = user.name
    shadow.querySelector('.name').textContent = user.name
    shadow.querySelector('.email').textContent = user.email
  }
}

customElements.define('user-card', UserCard)

4. ES Modules(ES 模块)

ES Modules 让我们可以模块化地组织 Web Components。

// components/Button.js
export class MyButton extends HTMLElement {
  constructor() {
    super()
    this.render()
  }

  render() {
    this.innerHTML = `
      <button class="my-button">
        <slot></slot>
      </button>
    `
  }
}

customElements.define('my-button', MyButton)
// components/Card.js
export class MyCard extends HTMLElement {
  constructor() {
    super()
    this.render()
  }

  render() {
    this.innerHTML = `
      <div class="card">
        <div class="card-header">
          <slot name="header"></slot>
        </div>
        <div class="card-body">
          <slot name="body"></slot>
        </div>
      </div>
    `
  }
}

customElements.define('my-card', MyCard)
<!-- 在 HTML 中引入 -->
<script type="module" src="./components/Button.js"></script>
<script type="module" src="./components/Card.js"></script>

<!-- 使用组件 -->
<my-card>
  <div slot="header">卡片标题</div>
  <div slot="body">卡片内容</div>
</my-card>

二、生命周期:组件的生老病死

Web Components 提供了完整的生命周期钩子,让我们可以在组件的不同阶段执行相应的逻辑。

class LifecycleDemo extends HTMLElement {
  constructor() {
    super()
    console.log('1. 构造函数执行')
  }

  // 组件被添加到 DOM 时调用
  connectedCallback() {
    console.log('2. 组件已连接到 DOM')
    this.render()
  }

  // 组件从 DOM 中移除时调用
  disconnectedCallback() {
    console.log('3. 组件已从 DOM 中移除')
    this.cleanup()
  }

  // 组件属性变化时调用
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`4. 属性 ${name}${oldValue} 变为 ${newValue}`)
    this.updateContent()
  }

  // 定义需要监听的属性
  static get observedAttributes() {
    return ['title', 'count']
  }

  render() {
    this.innerHTML = `
      <div class="lifecycle-demo">
        <h3>${this.getAttribute('title') || '默认标题'}</h3>
        <p>计数: ${this.getAttribute('count') || '0'}</p>
      </div>
    `
  }

  updateContent() {
    // 根据属性变化更新内容
    const title = this.getAttribute('title') || '默认标题'
    const count = this.getAttribute('count') || '0'

    this.querySelector('h3').textContent = title
    this.querySelector('p').textContent = `计数: ${count}`
  }

  cleanup() {
    // 清理工作,如移除事件监听器
    console.log('执行清理工作')
  }
}

customElements.define('lifecycle-demo', LifecycleDemo)
<!-- 使用示例 -->
<lifecycle-demo title="我的组件" count="5"></lifecycle-demo>

<script>
  // 动态修改属性
  setTimeout(() => {
    const demo = document.querySelector('lifecycle-demo')
    demo.setAttribute('count', '10')
  }, 2000)
</script>

三、属性与事件:组件的对外接口

属性系统

class ProductCard extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  // 定义可观察的属性
  static get observedAttributes() {
    return ['name', 'price', 'image', 'description']
  }

  // 属性变化时的处理
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render()
    }
  }

  // 获取属性值
  get name() {
    return this.getAttribute('name') || ''
  }

  set name(value) {
    this.setAttribute('name', value)
  }

  get price() {
    return this.getAttribute('price') || '0'
  }

  set price(value) {
    this.setAttribute('price', value)
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .product-card {
          border: 1px solid #ddd;
          border-radius: 8px;
          padding: 16px;
          max-width: 300px;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .product-image {
          width: 100%;
          height: 200px;
          object-fit: cover;
          border-radius: 4px;
        }
        .product-name {
          font-size: 18px;
          font-weight: bold;
          margin: 12px 0 8px 0;
        }
        .product-price {
          color: #e74c3c;
          font-size: 20px;
          font-weight: bold;
        }
        .product-description {
          color: #666;
          margin-top: 8px;
          line-height: 1.4;
        }
        .add-to-cart {
          background: #3498db;
          color: white;
          border: none;
          padding: 10px 20px;
          border-radius: 4px;
          cursor: pointer;
          margin-top: 12px;
          width: 100%;
        }
        .add-to-cart:hover {
          background: #2980b9;
        }
      </style>
      <div class="product-card">
        <img class="product-image" src="${this.getAttribute('image') || ''}" alt="${this.name}">
        <div class="product-name">${this.name}</div>
        <div class="product-price">¥${this.price}</div>
        <div class="product-description">${this.getAttribute('description') || ''}</div>
        <button class="add-to-cart">加入购物车</button>
      </div>
    `

    // 添加事件监听
    this.addEventListeners()
  }

  addEventListeners() {
    const button = this.shadowRoot.querySelector('.add-to-cart')
    button.addEventListener('click', () => {
      this.dispatchEvent(
        new CustomEvent('add-to-cart', {
          detail: {
            name: this.name,
            price: this.price,
          },
          bubbles: true,
        })
      )
    })
  }
}

customElements.define('product-card', ProductCard)
<!-- 使用组件 -->
<product-card
  name="iPhone 15"
  price="5999"
  image="https://example.com/iphone15.jpg"
  description="最新款 iPhone,性能强劲"
>
</product-card>

<script>
  // 监听组件事件
  document.addEventListener('add-to-cart', (event) => {
    console.log('添加到购物车:', event.detail)
    // 处理添加到购物车的逻辑
  })
</script>

四、插槽系统:灵活的内容分发

插槽(Slots)让我们可以在组件中插入自定义内容。

class MyModal extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  connectedCallback() {
    this.render()
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .modal-overlay {
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background: rgba(0, 0, 0, 0.5);
          display: flex;
          justify-content: center;
          align-items: center;
          z-index: 1000;
        }
        .modal-content {
          background: white;
          border-radius: 8px;
          padding: 24px;
          max-width: 500px;
          width: 90%;
          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
        }
        .modal-header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 16px;
          padding-bottom: 16px;
          border-bottom: 1px solid #eee;
        }
        .modal-title {
          font-size: 20px;
          font-weight: bold;
          margin: 0;
        }
        .close-button {
          background: none;
          border: none;
          font-size: 24px;
          cursor: pointer;
          color: #999;
        }
        .close-button:hover {
          color: #333;
        }
        .modal-body {
          margin-bottom: 16px;
        }
        .modal-footer {
          display: flex;
          justify-content: flex-end;
          gap: 12px;
        }
        .btn {
          padding: 8px 16px;
          border: none;
          border-radius: 4px;
          cursor: pointer;
        }
        .btn-primary {
          background: #3498db;
          color: white;
        }
        .btn-secondary {
          background: #95a5a6;
          color: white;
        }
      </style>
      <div class="modal-overlay">
        <div class="modal-content">
          <div class="modal-header">
            <h3 class="modal-title">
              <slot name="title">默认标题</slot>
            </h3>
            <button class="close-button">&times;</button>
          </div>
          <div class="modal-body">
            <slot name="body">默认内容</slot>
          </div>
          <div class="modal-footer">
            <slot name="footer">
              <button class="btn btn-secondary">取消</button>
              <button class="btn btn-primary">确定</button>
            </slot>
          </div>
        </div>
      </div>
    `

    this.addEventListeners()
  }

  addEventListeners() {
    const closeButton = this.shadowRoot.querySelector('.close-button')
    const overlay = this.shadowRoot.querySelector('.modal-overlay')

    closeButton.addEventListener('click', () => this.close())
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) this.close()
    })
  }

  close() {
    this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true }))
    this.remove()
  }
}

customElements.define('my-modal', MyModal)
<!-- 使用插槽 -->
<my-modal>
  <span slot="title">确认删除</span>
  <div slot="body">
    <p>您确定要删除这个项目吗?此操作不可撤销。</p>
  </div>
  <div slot="footer">
    <button class="btn btn-secondary">取消</button>
    <button class="btn btn-primary" style="background: #e74c3c;">删除</button>
  </div>
</my-modal>

五、与框架集成:最佳实践

在 React 中使用 Web Components

// Web Component
class MyCounter extends HTMLElement {
  constructor() {
    super()
    this.count = 0
    this.attachShadow({ mode: 'open' })
    this.render()
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .counter {
          display: flex;
          align-items: center;
          gap: 12px;
          padding: 16px;
          border: 1px solid #ddd;
          border-radius: 8px;
        }
        .count {
          font-size: 24px;
          font-weight: bold;
        }
        .btn {
          padding: 8px 16px;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          background: #3498db;
          color: white;
        }
        .btn:hover {
          background: #2980b9;
        }
      </style>
      <div class="counter">
        <button class="btn" id="decrement">-</button>
        <span class="count">${this.count}</span>
        <button class="btn" id="increment">+</button>
      </div>
    `

    this.addEventListeners()
  }

  addEventListeners() {
    this.shadowRoot.getElementById('increment').addEventListener('click', () => {
      this.count++
      this.render()
      this.dispatchEvent(
        new CustomEvent('count-change', {
          detail: { count: this.count },
        })
      )
    })

    this.shadowRoot.getElementById('decrement').addEventListener('click', () => {
      this.count--
      this.render()
      this.dispatchEvent(
        new CustomEvent('count-change', {
          detail: { count: this.count },
        })
      )
    })
  }
}

customElements.define('my-counter', MyCounter)
// React 组件中使用
import React, { useRef, useEffect } from 'react'

function App() {
  const counterRef = useRef(null)

  useEffect(() => {
    const counter = counterRef.current

    const handleCountChange = (event) => {
      console.log('计数变化:', event.detail.count)
    }

    counter.addEventListener('count-change', handleCountChange)

    return () => {
      counter.removeEventListener('count-change', handleCountChange)
    }
  }, [])

  return (
    <div>
      <h1>React + Web Components 示例</h1>
      <my-counter ref={counterRef}></my-counter>
    </div>
  )
}

在 Vue 中使用 Web Components

<template>
  <div>
    <h1>Vue + Web Components 示例</h1>
    <my-counter @count-change="handleCountChange"></my-counter>
    <p>当前计数: {{ currentCount }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentCount: 0,
    }
  },
  methods: {
    handleCountChange(event) {
      this.currentCount = event.detail.count
    },
  },
}
</script>

六、性能优化:让组件跑得更快

懒加载组件

// 懒加载 Web Component
class LazyComponent extends HTMLElement {
  constructor() {
    super()
    this.loaded = false
  }

  connectedCallback() {
    if (!this.loaded) {
      this.loadComponent()
    }
  }

  async loadComponent() {
    // 显示加载状态
    this.innerHTML = '<div>加载中...</div>'

    try {
      // 动态导入组件
      const { MyHeavyComponent } = await import('./HeavyComponent.js')

      // 创建组件实例
      const component = new MyHeavyComponent()
      this.appendChild(component)

      this.loaded = true
    } catch (error) {
      this.innerHTML = '<div>加载失败</div>'
      console.error('组件加载失败:', error)
    }
  }
}

customElements.define('lazy-component', LazyComponent)

组件缓存

// 组件缓存管理器
class ComponentCache {
  constructor() {
    this.cache = new Map()
  }

  get(key) {
    return this.cache.get(key)
  }

  set(key, component) {
    this.cache.set(key, component)
  }

  has(key) {
    return this.cache.has(key)
  }
}

const componentCache = new ComponentCache()

// 使用缓存的组件
class CachedComponent extends HTMLElement {
  constructor() {
    super()
    this.componentKey = this.getAttribute('component-key')
  }

  connectedCallback() {
    if (componentCache.has(this.componentKey)) {
      // 使用缓存的组件
      const cachedComponent = componentCache.get(this.componentKey)
      this.appendChild(cachedComponent.cloneNode(true))
    } else {
      // 创建新组件并缓存
      this.createComponent()
    }
  }

  createComponent() {
    // 创建组件的逻辑
    const component = document.createElement('div')
    component.textContent = '新组件'
    componentCache.set(this.componentKey, component)
    this.appendChild(component)
  }
}

customElements.define('cached-component', CachedComponent)

七、实际项目中的应用场景

1. 设计系统组件库

// 按钮组件
class DesignButton extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  static get observedAttributes() {
    return ['variant', 'size', 'disabled']
  }

  attributeChangedCallback() {
    this.render()
  }

  render() {
    const variant = this.getAttribute('variant') || 'primary'
    const size = this.getAttribute('size') || 'medium'
    const disabled = this.hasAttribute('disabled')

    this.shadowRoot.innerHTML = `
      <style>
        .btn {
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-family: inherit;
          font-weight: 500;
          transition: all 0.2s;
        }
        
        .btn:disabled {
          opacity: 0.6;
          cursor: not-allowed;
        }
        
        /* 变体样式 */
        .btn--primary {
          background: #3498db;
          color: white;
        }
        .btn--primary:hover:not(:disabled) {
          background: #2980b9;
        }
        
        .btn--secondary {
          background: #95a5a6;
          color: white;
        }
        .btn--secondary:hover:not(:disabled) {
          background: #7f8c8d;
        }
        
        /* 尺寸样式 */
        .btn--small {
          padding: 6px 12px;
          font-size: 12px;
        }
        .btn--medium {
          padding: 8px 16px;
          font-size: 14px;
        }
        .btn--large {
          padding: 12px 24px;
          font-size: 16px;
        }
      </style>
      <button class="btn btn--${variant} btn--${size}" ${disabled ? 'disabled' : ''}>
        <slot></slot>
      </button>
    `
  }
}

customElements.define('design-button', DesignButton)

2. 第三方集成组件

// 地图组件
class MapComponent extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.map = null
  }

  static get observedAttributes() {
    return ['latitude', 'longitude', 'zoom']
  }

  attributeChangedCallback() {
    if (this.map) {
      this.updateMap()
    }
  }

  connectedCallback() {
    this.render()
    this.initMap()
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .map-container {
          width: 100%;
          height: 400px;
          border: 1px solid #ddd;
          border-radius: 8px;
        }
      </style>
      <div class="map-container" id="map"></div>
    `
  }

  async initMap() {
    // 动态加载地图 API
    if (!window.google) {
      await this.loadGoogleMapsAPI()
    }

    const mapContainer = this.shadowRoot.getElementById('map')
    const lat = parseFloat(this.getAttribute('latitude')) || 39.9042
    const lng = parseFloat(this.getAttribute('longitude')) || 116.4074
    const zoom = parseInt(this.getAttribute('zoom')) || 10

    this.map = new google.maps.Map(mapContainer, {
      center: { lat, lng },
      zoom: zoom,
    })
  }

  loadGoogleMapsAPI() {
    return new Promise((resolve) => {
      const script = document.createElement('script')
      script.src = 'https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY'
      script.onload = resolve
      document.head.appendChild(script)
    })
  }

  updateMap() {
    if (this.map) {
      const lat = parseFloat(this.getAttribute('latitude'))
      const lng = parseFloat(this.getAttribute('longitude'))
      const zoom = parseInt(this.getAttribute('zoom'))

      this.map.setCenter({ lat, lng })
      this.map.setZoom(zoom)
    }
  }
}

customElements.define('map-component', MapComponent)

八、常见问题与解决方案

问题1:样式不生效

// 错误做法:样式在 Shadow DOM 外部定义
class BadComponent extends HTMLElement {
  constructor() {
    super()
    this.innerHTML = '<div class="my-component">内容</div>'
  }
}

// 正确做法:样式在 Shadow DOM 内部定义
class GoodComponent extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.shadowRoot.innerHTML = `
      <style>
        .my-component {
          color: red;
        }
      </style>
      <div class="my-component">内容</div>
    `
  }
}

问题2:事件监听器内存泄漏

class EventComponent extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.boundHandler = this.handleClick.bind(this)
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = '<button>点击我</button>'
    this.shadowRoot.querySelector('button').addEventListener('click', this.boundHandler)
  }

  disconnectedCallback() {
    // 清理事件监听器
    this.shadowRoot.querySelector('button').removeEventListener('click', this.boundHandler)
  }

  handleClick() {
    console.log('按钮被点击')
  }
}

问题3:属性变化不响应

class ReactiveComponent extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  // 必须定义 observedAttributes
  static get observedAttributes() {
    return ['title', 'count']
  }

  // 属性变化时会被调用
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render()
    }
  }

  render() {
    this.shadowRoot.innerHTML = `
      <div>
        <h3>${this.getAttribute('title') || '默认标题'}</h3>
        <p>计数: ${this.getAttribute('count') || '0'}</p>
      </div>
    `
  }
}

结语

Web Components 为我们提供了一种不依赖框架的组件化解决方案。它特别适合:

  1. 跨框架复用:一次编写,在 React、Vue、Angular 中都能使用
  2. 渐进式采用:可以在现有项目中逐步引入
  3. 性能优化:原生浏览器支持,性能优秀
  4. 长期维护:不依赖第三方框架,技术栈稳定

但也要注意 Web Components 的局限性:

  • 浏览器兼容性(需要 polyfill)
  • 开发体验不如框架友好
  • 生态系统相对较小

选择 Web Components 还是框架组件,要根据具体项目需求来决定。记住:技术服务于业务,选择最适合的解决方案